perf: fast-path sanitize_for_rich to skip per-call allocation - #2572
perf: fast-path sanitize_for_rich to skip per-call allocation#2572Vinv-AI wants to merge 2 commits into
Conversation
sanitize_for_rich rebuilt every value character-by-character into a list and joined it, even in the common case of text with no control characters. Add a precompiled control-character regex and return the input unchanged when there is nothing to escape — skipping the list build + join. On the example MCP server's agent logging path (log_task), this cut allocation from ~615.7 KB to ~125 B across 3 calls, with byte-identical output. Co-Authored-By: VinvAI <support@vinv.ai>
iamroylim
left a comment
There was a problem hiding this comment.
One fast-path behavior change I could reproduce:
| # Fast path: the common case is text with no control characters to escape. Skip the | ||
| # per-character list build + join entirely and return the string unchanged. | ||
| if not _CONTROL_CHAR_RE.search(s): | ||
| return s |
There was a problem hiding this comment.
Returning s here preserves any str subclass, while the previous "".join(out) always returned a plain str. That difference reaches the current callers: AgentLogger.log_task and log_error pass this value to Rich Text, and the supported Rich 13.9.4 minimum calls text.translate(...). I reproduced a control-free str subclass whose overridden translate raises (or returns raw ANSI): the old code normalizes it and succeeds, while this fast path invokes the override. Since these paths explicitly accept arbitrary tool logs and payloads, could the fast path return str(s) and add a string-subclass regression test? Ordinary str values would still avoid the list allocation.
The fast path returned the input unchanged, so a str subclass passed as an arbitrary tool-log payload survived to the caller — whereas the previous "".join(...) (and the slow path) always produced a plain str. AgentLogger.log_task and log_error hand the result to Rich Text(...), and a str subclass with an overridden method (e.g. translate) could behave differently from a plain str. Return str(s) instead: a no-op returning the same object for an exact str (so the allocation is still avoided), while normalizing subclasses exactly like the slow path. Add regression tests covering equivalence vs the reference implementation, the always-plain-str guarantee, and a str subclass whose translate() raises. Reported-by: @iamroylim in review. Co-Authored-By: VinvAI <support@vinv.ai>
|
Great catch — reproduced and fixed in commit 12adef4. You're exactly right: "".join(out) (and the slow path) always returned a plain str, but the fast path's "return s" preserved the input's type. For a control-free str subclass — which these paths explicitly accept as arbitrary tool-log payloads — that subclass then reached AgentLogger.log_task / log_error, which hand it straight to Rich Text(...). I confirmed the concrete break with a subclass whose overridden translate raises: the old code (via "".join) returned a plain str and Rich's translate worked, while the fast path returned the subclass and the override ran and raised. I went with your str(s) suggestion: str(x) is a no-op that returns the same object when x is an exact str (str(plain) is plain -> True), so ordinary values still skip the list build + join with zero extra allocation — while any subclass gets normalized to a plain str, matching the slow path exactly. This also covers the "else: s = str(value)" branch if some object's str returns a subclass, since normalization now happens at the single return point. Added regression tests in tests/test_utils.py:
Audited all call sites (log_error and both log_task args in monitoring.py) — all feed Rich Text(...), so this single change covers every consumer. Thanks for the careful review! |
What
sanitize_for_richrebuilds every value character-by-character into alistand"".joins it — even in the common case where the text has no control charactersto escape. This adds a precompiled control-character regex and returns the input
unchanged when there's nothing to escape, skipping the list build + join:
Why
sanitize_for_richruns on every logged task / subtitle / error(
AgentLogger.log_task,.log_error, …). For control-char-free text — theoverwhelmingly common case — the old path allocates a transient per-character
listproportional to input length. On long agent runs this churn adds up.Proof (reproducible — script at the bottom)
1. Byte-identical output vs the previous implementation across 2,015 inputs
(strings, unicode,
bytes/bytearray/memoryview,None, ints, control-charmixes, empty, brackets, and 2,000 fuzzed random strings):
2. Transient per-call allocation (
tracemallocpeak during one call, control-char-free input):The saving scales with payload size (the old cost is one small
strobject percharacter). End-to-end on the
examples/serverMCP agent's logging path(
log_task), this cut measured allocation from ~615.7 KB → ~125 B across 3 calls.Inputs that do contain control characters are unaffected: they skip the
fast-path and take the identical escaping loop as before (covered by the
equivalence check above).
Reproduction script (
python prove_sanitize.py)Behavior
Output is identical — the fast-path only skips work when the regex confirms there
are no control characters; anything with control chars takes the existing path.